● Zion Boggan
repos/Sightline/frontend/src/app/evidence/[id]/page.tsx
zionboggan.com ↗
75 lines · tsx
History for this file →
1
"use client";
2
import { useEffect, useState } from "react";
3
import { useParams } from "next/navigation";
4
import Link from "next/link";
5
import { api, downloadAuthed, EvidenceDetail } from "@/lib/api";
6
 
7
export default function EvidenceViewer() {
8
  const { id } = useParams<{ id: string }>();
9
  const [ev, setEv] = useState<EvidenceDetail | null>(null);
10
  const [err, setErr] = useState<string | null>(null);
11
 
12
  useEffect(() => {
13
    api.evidenceDetail(id)
14
      .then(setEv)
15
      .catch((e) => setErr(e.status === 401 ? "Sign in to view evidence." : "Evidence not found."));
16
  }, [id]);
17
 
18
  if (err) return <div className="badge danger">{err}</div>;
19
  if (!ev) return <div className="muted">Loading…</div>;
20
 
21
  const isImage = (ev.content_type || "").startsWith("image/");
22
 
23
  return (
24
    <div>
25
      <h1>{ev.filename}</h1>
26
      <p className="muted">
27
        Evidence in <Link href={`/investigations/${ev.investigation_id}`}>investigation {ev.investigation_id.slice(0, 8)}</Link>
28
      </p>
29
 
30
      <div className="grid">
31
        <div className="card"><div className="muted">Type</div><div>{ev.content_type || "unknown"}</div></div>
32
        <div className="card"><div className="muted">Size</div><div>{(ev.size_bytes / 1024).toFixed(1)} KB</div></div>
33
        <div className="card"><div className="muted">Uploaded</div><div>{new Date(ev.created_at).toLocaleString()}</div></div>
34
        <div className="card"><div className="muted">Source</div><div>{ev.source_id ? ev.source_id.slice(0, 8) : "-"}</div></div>
35
      </div>
36
 
37
      <h2>Integrity</h2>
38
      <div className="card">
39
        <div className="muted">SHA-256</div>
40
        <div style={{ fontFamily: "monospace", fontSize: 12, wordBreak: "break-all" }}>{ev.sha256}</div>
41
        <div style={{ marginTop: 10 }}>
42
          <button className="primary" onClick={() => downloadAuthed(`/evidence/${ev.id}/download`, ev.filename)}>
43
            Download (integrity-checked)
44
          </button>
45
        </div>
46
        {ev.description && <p className="muted" style={{ marginTop: 10 }}>{ev.description}</p>}
47
      </div>
48
 
49
      {isImage && (
50
        <>
51
          <h2>Preview</h2>
52
          <div className="card muted">Image preview requires an authenticated blob fetch - use Download above.</div>
53
        </>
54
      )}
55
 
56
      <h2>Chain of custody</h2>
57
      <div className="card">
58
        <table>
59
          <thead><tr><th>When</th><th>Action</th><th>Actor</th><th>Detail</th></tr></thead>
60
          <tbody>
61
            {ev.chain_of_custody.map((c, i) => (
62
              <tr key={i}>
63
                <td className="muted" style={{ whiteSpace: "nowrap" }}>{new Date(c.at).toLocaleString()}</td>
64
                <td><span className="badge">{c.action}</span></td>
65
                <td className="muted">{c.actor ? c.actor.slice(0, 8) : "-"}</td>
66
                <td className="muted" style={{ fontSize: 12 }}>{c.metadata ? JSON.stringify(c.metadata) : "-"}</td>
67
              </tr>
68
            ))}
69
            {!ev.chain_of_custody.length && <tr><td colSpan={4} className="muted">No custody events.</td></tr>}
70
          </tbody>
71
        </table>
72
      </div>
73
    </div>
74
  );
75
}